title: Example description: iOS 18+: onScrollGeometryChange selects native fields and reports old/new values; onScrollPhaseChange provides geometry and optional velocity context; onScrollVisibilityChange observes individual elements. Includes JSX and chained examples and iOS 17 behavior.


import { Button, Color, ForEach, HStack, KeywordPoint, LazyVStack, Navigation, NavigationStack, Picker, RoundedRectangle, Script, ScrollView, Text, useRef, useState, VStack, type ScrollPhase } from "scripting"

function ScrollObservationExample() {
  const latestY = useRef(0)
  const [settledY, setSettledY] = useState(0)
  const [phase, setPhase] = useState<ScrollPhase>("idle")
  const [firstVisible, setFirstVisible] = useState<boolean | null>(null)
  return <VStack alignment="leading" spacing={8}>
    <Text font="headline">Scroll observation (iOS 18+)</Text>
    <Text font="caption">Phase: {phase}, last settled y: {settledY.toFixed(1)}</Text>
    <Text font="caption">First row visible: {firstVisible === null ? "waiting" : String(firstVisible)}</Text>
    <ScrollView
      frame={{ height: 240 }}
      onScrollGeometryChange={{
        of: "contentOffset.y",
        // Keep frequent samples outside state. Update the label when idle.
        onChanged: (_, y) => { latestY.current = y },
      }}
      onScrollPhaseChange={(_, next, context) => {
        setPhase(next)
        if (next === "idle") setSettledY(context.geometry.contentOffset.y)
      }}
    >
      <LazyVStack spacing={0}>
        {Array.from({ length: 30 }, (_, index) => <Text
          key={`observe-${index}`}
          frame={{ height: 60 }}
          onScrollVisibilityChange={index === 0 ? {
            threshold: 0.5,
            onChanged: setFirstVisible,
          } : undefined}
        >Observed row {index + 1}</Text>)}
      </LazyVStack>
    </ScrollView>
  </VStack>
}

function Example() {
  const colors: Color[] = [
    "systemRed",
    "systemOrange",
    "systemYellow",
    "systemGreen",
    "systemBlue",
    "systemPurple",
    "systemIndigo",
    "systemPink",
  ]
  const [scrollAnchor, setScrollAnchor] = useState<KeywordPoint>("bottom")

  // scrollPosition demo —— 把 leading 可见 item id 双向绑到 state;
  // 子节点用 `key=...`,bridge 会映射到 SwiftUI `.id()`。
  const positionItems = Array.from({ length: 50 }, (_, i) => ({
    id: `pos-${i}`,
    title: `Row ${i + 1}`,
    color: colors[i % colors.length],
  }))
  const [visibleId, setVisibleId] = useState<string | null>(null)

  // onScrollTargetVisibilityChange demo —— iOS 18+,跟踪当前可见 id 集合(threshold ≥ 0.5)。
  const visItems = Array.from({ length: 30 }, (_, i) => ({
    id: `vis-${i}`,
    title: `Item ${i + 1}`,
    color: colors[i % colors.length],
  }))
  const [visibleIds, setVisibleIds] = useState<string[]>([])

  return <NavigationStack>
    <ScrollView
      navigationTitle={"ScrollView"}
      defaultScrollAnchor={scrollAnchor}
      navigationBarTitleDisplayMode={"inline"}
      key={scrollAnchor}
    >
      <VStack
        spacing={16}
        padding
      >
        <Picker
          title={"Default Scroll Anchor"}
          value={scrollAnchor}
          onChanged={setScrollAnchor as any}
          pickerStyle={"menu"}
        >
          <Text tag={"top"}>Top</Text>
          <Text tag={"center"}>Center</Text>
          <Text tag={"bottom"}>Bottom</Text>
        </Picker>

        <ScrollView
          axes={"horizontal"}
          frame={{
            height: 64
          }}
        >
          <HStack spacing={8}>
            <ForEach
              count={15}
              itemBuilder={index =>
                <RoundedRectangle
                  key={index.toString()}
                  fill={"systemIndigo"}
                  cornerRadius={6}
                  frame={{
                    width: 64,
                    height: 64,
                  }}
                  overlay={
                    <Text>{index}</Text>
                  }
                />
              }
            />
          </HStack>
        </ScrollView>

        <ForEach
          count={colors.length}
          itemBuilder={index => {
            const color = colors[index]
            return <RoundedRectangle
              key={color}
              fill={color}
              cornerRadius={16}
              frame={{
                height: 100
              }}
            />
          }}
        />

        {/* scrollPosition section —— 把 leading 可见 item id 同步给 JS,并支持 jump-to-id */}
        <VStack alignment={"leading"} spacing={8}>
          <Text font={"headline"}>scrollPosition(id:anchor:)</Text>
          <Text font={"caption"} foregroundStyle={"secondaryLabel"}>
            Two-way binds the leading visible item's id. Tap a Jump button to scroll to a row.
          </Text>
          <HStack spacing={6}>
            <Text font={"caption2"} foregroundStyle={"secondaryLabel"}>Visible:</Text>
            <Text font={"caption"} monospaced>{visibleId ?? "—"}</Text>
          </HStack>
          <HStack spacing={8}>
            <Button title={"Jump to first"} action={() => setVisibleId("pos-0")} buttonStyle={"borderedProminent"} controlSize={"small"} />
            <Button title={"Jump to 25"} action={() => setVisibleId("pos-24")} buttonStyle={"borderedProminent"} controlSize={"small"} />
            <Button title={"Jump to last"} action={() => setVisibleId("pos-49")} buttonStyle={"borderedProminent"} controlSize={"small"} />
          </HStack>
          <ScrollView
            frame={{ height: 280 }}
            background={
              <RoundedRectangle cornerRadius={12} fill={"secondarySystemBackground"} />
            }
            scrollPosition={{
              value: visibleId,
              onChanged: (id) => setVisibleId(id as string | null),
              anchor: "top",
            }}
          >
            <LazyVStack scrollTargetLayout spacing={8} padding={8}>
              {positionItems.map(it => (
                <HStack
                  key={it.id}
                  spacing={12}
                  padding={10}
                  background={
                    <RoundedRectangle cornerRadius={10} fill={{ color: it.color, opacity: 0.18 }} />
                  }
                >
                  <RoundedRectangle cornerRadius={6} frame={{ width: 24, height: 24 }} fill={it.color} />
                  <Text font={"body"}>{it.title}</Text>
                  <Text font={"caption"} foregroundStyle={"secondaryLabel"}>{it.id}</Text>
                </HStack>
              ))}
            </LazyVStack>
          </ScrollView>
        </VStack>

        {/* onScrollTargetVisibilityChange section —— iOS 18+,整组可见 id 跟踪 */}
        <ScrollObservationExample />
        <VStack alignment={"leading"} spacing={8}>
          <Text font={"headline"}>onScrollTargetVisibilityChange</Text>
          <Text font={"caption"} foregroundStyle={"secondaryLabel"}>
            iOS 18+. Reports the array of currently-visible scroll-target ids (≥ 50% visible).
          </Text>
          <Text font={"caption2"} foregroundStyle={"secondaryLabel"}>
            visible ({visibleIds.length}):
          </Text>
          <Text font={"caption"} monospaced>
            {visibleIds.length > 0 ? visibleIds.join(", ") : "—"}
          </Text>
          <ScrollView
            frame={{ height: 220 }}
            background={
              <RoundedRectangle cornerRadius={12} fill={"secondarySystemBackground"} />
            }
            onScrollTargetVisibilityChange={{
              idType: "string",
              threshold: 0.5,
              onChanged: (ids) => setVisibleIds(ids as string[]),
            }}
          >
            <LazyVStack scrollTargetLayout spacing={8} padding={8}>
              {visItems.map(it => (
                <HStack
                  key={it.id}
                  spacing={12}
                  padding={10}
                  background={
                    <RoundedRectangle cornerRadius={10} fill={{ color: it.color, opacity: 0.18 }} />
                  }
                >
                  <RoundedRectangle cornerRadius={6} frame={{ width: 24, height: 24 }} fill={it.color} />
                  <Text font={"body"}>{it.title}</Text>
                  <Text font={"caption"} foregroundStyle={"secondaryLabel"}>{it.id}</Text>
                </HStack>
              ))}
            </LazyVStack>
          </ScrollView>
        </VStack>
      </VStack>
    </ScrollView>
  </NavigationStack>
}

async function run() {
  await Navigation.present({
    element: <Example />
  })

  Script.exit()
}

run()